TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { and, eq } from '@/lib/db';5import { db, collections, collectionItems } from '@/lib/db';6import { requireUser } from '@/lib/auth/session';7import { getAssetBrief, listCollections, loadItems } from '@/lib/account/queries';8import { valueItem } from '@/lib/account/portfolio';9import { getDisplay } from '@/lib/account/display';10import { deleteItemAction, moveItemAction, removeItemPhotoAction } from '@/lib/account/actions';11import { PageHeader, btnDanger, btnSecondary } from '@/components/account/page-header';12import { ValueSourceBadge } from '@/components/account/portfolio-widgets';13import { Card, CardHeader, Delta, Stat } from '@/components/ui/primitives';14import { confidenceLabel, fmtPct } from '@/lib/format';15import { EditItemForm } from '../../add-item-form';16import { PhotoUploadForm } from './photo-form';1718export const metadata: Metadata = { title: 'Item', robots: { index: false } };1920export default async function ItemPage({ params }: { params: Promise<{ id: string; itemId: string }> }) {21 const { id, itemId } = await params;22 const u = await requireUser(`/collections/${id}/items/${itemId}`);23 const own = await db().select({ id: collectionItems.id }).from(collectionItems).innerJoin(collections, eq(collections.id, collectionItems.collectionId)).where(and(eq(collectionItems.id, itemId), eq(collections.id, id), eq(collections.userId, u.id))).limit(1);24 if (!own[0]) notFound();25 const loaded = (await loadItems([id])).find((i) => i.id === itemId);26 if (!loaded) notFound();27 const v = valueItem(loaded);28 const brief = await getAssetBrief(loaded.assetId);29 const d = await getDisplay();30 const others = (await listCollections(u.id)).filter((c) => c.collection.id !== id);3132 return (33 <>34 <nav className="mb-2 text-xs text-muted">35 <Link href="/collections" className="hover:text-fg">36 Collections37 </Link>{' '}38 /{' '}39 <Link href={`/collections/${id}`} className="hover:text-fg">40 Collection41 </Link>{' '}42 / <span className="text-fg">{loaded.title}</span>43 </nav>44 <PageHeader45 title={loaded.title}46 description={47 <>48 {loaded.categorySlug.replace(/_/g, ' ')} · <ValueSourceBadge item={v} />{' '}49 <Link href={`/asset/${loaded.assetSlug}`} className="underline-offset-4 hover:underline">50 Open asset page →51 </Link>52 </>53 }54 actions={55 <>56 {others.length ? (57 <form action={moveItemAction} className="flex items-center gap-1">58 <input type="hidden" name="itemId" value={itemId} />59 <select name="toCollectionId" className="h-9 rounded-md border border-border bg-elevated px-2 text-[13px]" defaultValue="">60 <option value="" disabled>61 Move to…62 </option>63 {others.map((o) => (64 <option key={o.collection.id} value={o.collection.id}>65 {o.collection.name}66 </option>67 ))}68 </select>69 <button className={btnSecondary}>Move</button>70 </form>71 ) : null}72 <form action={deleteItemAction}>73 <input type="hidden" name="itemId" value={itemId} />74 <button className={btnDanger}>Remove item</button>75 </form>76 </>77 }78 />79 <div className="mb-5 grid grid-cols-2 gap-4 sm:grid-cols-4">80 <Stat label="Current value" value={v.valueUsd === null ? '—' : d.money(v.valueUsd)} sub={v.valueUsd === null ? 'no market evidence yet' : v.confidence !== null ? `confidence ${confidenceLabel(v.confidence)}` : 'manual value'} />81 <Stat label="Cost" value={v.costUsd === null ? '—' : d.money(v.costUsd)} sub={loaded.purchasePriceNative !== null && loaded.acquiredCurrency && loaded.acquiredCurrency !== 'USD' ? `${loaded.purchasePriceNative} ${loaded.acquiredCurrency} at acquisition FX` : v.acquiredAt ? `acquired ${v.acquiredAt}` : 'no purchase price'} />82 <Stat label="Gain" value={<Delta value={v.gainPct} className="text-lg" />} sub={v.gainUsd === null ? '—' : d.money(v.gainUsd)} />83 <Stat label="Annualised" value={fmtPct(v.annualizedReturn)} sub={v.holdingDays !== null ? `${v.holdingDays} days held` : 'needs acquisition date'} />84 </div>85 {brief?.stats ? (86 <Card className="mb-5">87 <CardHeader title="Market context" subtitle="From the RareIndex asset record" />88 <dl className="grid grid-cols-2 gap-y-2 p-4 text-xs sm:grid-cols-4">89 <dt className="text-subtle">Asset RIV</dt>90 <dd className="num">{brief.stats.rivUsd ? `${d.money(brief.stats.rivUsd)} (${brief.stats.rivSampleSize} sales)` : '—'}</dd>91 <dt className="text-subtle">Latest sale</dt>92 <dd className="num">{brief.stats.latestSaleUsd ? d.money(brief.stats.latestSaleUsd) : '—'}</dd>93 <dt className="text-subtle">30d / 1y</dt>94 <dd className="num">95 {fmtPct(brief.stats.change30d)} / {fmtPct(brief.stats.change1y)}96 </dd>97 <dt className="text-subtle">Liquidity · rarity</dt>98 <dd className="num">99 {brief.stats.liquidityScore ?? '—'} · {brief.stats.rarityScore ?? '—'}100 </dd>101 <dt className="text-subtle">Active listings</dt>102 <dd className="num">{brief.stats.activeListings}{brief.stats.minAskUsd ? ` · min ask ${d.money(brief.stats.minAskUsd)}` : ''}</dd>103 <dt className="text-subtle">Sales 30d</dt>104 <dd className="num">{brief.stats.sales30d}</dd>105 </dl>106 </Card>107 ) : null}108 <div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">109 <Card>110 <CardHeader title="Details" />111 <div className="p-4">112 <EditItemForm initial={{ itemId, asset: { id: loaded.assetId, title: loaded.title, categorySlug: loaded.categorySlug, year: null, heroImageUrl: loaded.heroImageUrl, rivUsd: loaded.assetRivUsd }, quantity: loaded.quantity, acquiredAt: loaded.acquiredAt, purchasePrice: loaded.purchasePriceNative, purchaseCurrency: loaded.acquiredCurrency, source: loaded.source, grader: loaded.grader, grade: loaded.grade, certificationNumber: loaded.certificationNumber, serial: loaded.serial, condition: loaded.condition, notes: loaded.notes, tags: loaded.tags, manualValueUsd: loaded.manualValueUsd, soldAt: loaded.soldAt, soldPriceUsd: loaded.soldPriceUsd ?? null }} />113 </div>114 </Card>115 <Card>116 <CardHeader title="Photos" subtitle={`${loaded.photos.length}/12`} />117 <div className="space-y-3 p-4">118 {loaded.photos.length ? (119 <ul className="grid grid-cols-3 gap-2">120 {loaded.photos.map((p) => (121 <li key={p} className="group relative">122 {/* eslint-disable-next-line @next/next/no-img-element */}123 <img src={p} alt="" className="aspect-square w-full rounded-sm border border-border object-cover" />124 <form action={removeItemPhotoAction} className="absolute right-1 top-1">125 <input type="hidden" name="itemId" value={itemId} />126 <input type="hidden" name="url" value={p} />127 <button aria-label="Remove photo" className="rounded-sm bg-black/60 px-1.5 text-[10px] text-white opacity-0 group-hover:opacity-100">128 ✕129 </button>130 </form>131 </li>132 ))}133 </ul>134 ) : (135 <p className="text-xs text-subtle">No photos yet. Photos stay private unless the collection is public.</p>136 )}137 <PhotoUploadForm itemId={itemId} />138 </div>139 </Card>140 </div>141 </>142 );143}144